Skip to content

feat(studio): patchRuntimeTweenInPlace — update a tween's values in place#1612

Closed
miguel-heygen wants to merge 2 commits into
feat/studio-gsap-keyframe-editingfrom
feat/instant-patch-helper
Closed

feat(studio): patchRuntimeTweenInPlace — update a tween's values in place#1612
miguel-heygen wants to merge 2 commits into
feat/studio-gsap-keyframe-editingfrom
feat/instant-patch-helper

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds patchRuntimeTweenInPlace, a defensive helper that updates a single tween's values directly in the preview iframe's runtime timeline (window.__timelines) and re-seeks to the current playhead — so a value-only manual edit (a tl.set position/rotation/scale, or a keyframe value/position change) is reflected instantly, without re-running the whole composition.

This is the foundation for flicker-free instant editing. It doesn't change any commit path on its own; runCommit opts in later in the stack and falls back to the existing soft reload whenever the helper declines. The new module is fully unit-tested (12 scenarios), and the supporting gsapRuntimeKeyframes exports are widened so read and write resolve "which tween" identically.

What's changed

packages/studio/src/hooks/gsapRuntimePatch.ts (new)

  • patchRuntimeTweenInPlace(iframe, selector, change, compositionId?) — resolves the live tween, applies the change in place, invalidate()s the tween + its owning timeline (so GSAP re-reads vars on next render), then re-seeks the player to the current playhead. Returns true on a confident patch, false otherwise. Wrapped in a top-level try/catch so it never throws — any internal failure becomes false.
  • RuntimeTweenChange — discriminated union: { kind: "set"; props: SetPatchProps } and { kind: "keyframes"; keyframes: KeyframeStep[] }. SetPatchProps covers x, y, rotation, scaleX, scaleY, scale, opacity; KeyframeStep is the numeric GSAP array-keyframe form.
  • patchSet — writes only the provided numeric channels onto tween.vars. Returns false if vars is missing, the set carries a motionPath, or any provided channel is non-finite; true only if it touched a channel.
  • patchKeyframes — replaces vars.keyframes with a deep-copied static array. Declines when vars is missing, the tween carries a motionPath (arc motion is a path, not channel keyframes), the tween isn't already array-keyframe-shaped (shape-match guard), or any step value isn't finite.
  • playerOf — reads __player behind a try/catch so cross-origin/teardown access can't throw.
  • The re-seek — pulls time from __player.getTime()timeline.time()0, guards non-finite, then __player.seek(...) to re-render at the playhead.

packages/studio/src/hooks/gsapRuntimeKeyframes.ts

  • resolveRuntimeTween(iframe, selector, kind, compositionId?) (new export) — resolves the live tween using the same all-timelines scan readRuntimeKeyframes uses, so reader and writer always agree on which tween is edited. kind: "keyframe" skips zero-duration sets and prefers the tween straddling the playhead; kind: "set" picks the zero-duration set/hold. Falls back to the first match; returns null when nothing matches. Returns the tween + its owning timeline.
  • Widened exports: RuntimeTween, RuntimeTimeline, timelinesOf, new ResolvedRuntimeTween; RuntimeTween/RuntimeTimeline gain an optional invalidate?().

Defensive false-returns

The caller falls back to the soft reload on false, so declining is always safe — far safer than a silent mis-patch. Returns false (never throws, never mutates) when the tween can't be confidently located, the change can't be safely expressed (motionPath arc, dynamic/computed values, set-vs-keyframes shape mismatch), or any internal error occurs.

Why

Today applying a manual value edit re-runs the composition timeline, which causes a visible flash — the playhead resets and the scene rebuilds before settling. For incremental value-only edits that flash breaks the direct-manipulation feel. patchRuntimeTweenInPlace mutates the one tween that changed, invalidates it, and re-seeks in place — no teardown, no rebuild, no flash. Because it's strictly opt-in and falls back to the proven soft-reload path whenever it can't apply confidently, it carries zero regression risk for the cases it doesn't handle.

Testing

gsapRuntimePatch.test.ts — 12 scenarios against fixtures mimicking the runtime timeline shape (a timeline with getChildren(deep), child tweens with vars/targets/duration/startTime/invalidate, and a __player clock):

  • Sets: patches x/y (re-seek reflects new values; invalidate + seek fire once); patches only rotation; patches only scale (leaving opacity).
  • Keyframes: rebuilds keyframes (moved middle keyframe updates, others stay); preserves ease.
  • Defensive false: no matching tween (seek never called); selector resolves to no element; motionPath arc (no invalidate/seek); dynamic/computed value; keyframes-vs-set shape mismatch; never throws (exploding vars/getChildren).
  • Composition isolation: patches only the owning timeline's tween; a same-channel tween in another composition is untouched and never invalidated.

Stack

Part of the GSAP keyframe/motion-path stack: #1553 → #1554 → #1555 → #1607 → #1608 → #1609 → #1610 → #1611 → #1567 → #1612 → #1613 → #1605. First instant-editing PR, on #1567. Builds independently; the combined diff across the stack is byte-identical to the originally-reviewed work.

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at dc79a2a6. Solid foundation slice; defensive-by-default contract is clean and the 12-scenario test suite covers the decline paths well. Couple of concerns + a few nits.

Concerns

  • gsapDragCommit.ts callers select existingSet via findPositionSetAnimation (matches on "x" in props || "y" in props) and findRotationSetAnimation (matches on "rotation") — these can pick different tweens for the same selector when the source has separate tl.set("#el",{x,y}) and tl.set("#el",{rotation}) calls. But patchRuntimeTweenInPlace resolves by selector + kind:"set" only — no channel disambiguation — and resolveRuntimeTween returns the first zero-duration tween for that target. So a position-fast-path can land on the rotation set's tween (and vice versa): patchSet then writes new x/y channels INTO the rotation tween's vars, leaving the position tween's vars.x/y stale. The source file write is correct (separate update-property on the right animationId), but the runtime is now contradictory until the next full reload. Worth either (a) accepting a channels hint to resolveRuntimeTween and preferring the tween that already carries the channel, or (b) explicitly documenting "fast path expects merged-set shape; multi-set elements fall back to soft reload" and gating accordingly. This bites #1613, not this PR per se, but the helper enables it.

  • patchSet writes channels onto vars only if Number.isFinite(next). But it doesn't check whether vars[ch] was previously a string expression (e.g. x: "+=10" or x: "random(0,50)") — overwriting that with a number silently drops dynamic intent. keyframesAreStatic rejects non-finite VALUES in the patch input; the equivalent for patchSet would be "decline if the existing vars[ch] is a string and we're about to replace it with a number." Today's caller path (drag from a static element) won't hit this, but the foundation is general; cheap to add a guard so the helper stays safe for keyframe-value-edit callers later in the stack.

Nits

  • playerOf try/catchs cross-origin access, but the surrounding patchRuntimeTweenInPlace has its own top-level try/catch — the inner one is belt-and-braces, fine but redundant. (nit)
  • vars[ch] = next mutates a Record<string, unknown> typed object via a typed-channel key; the assignment is sound but reads slightly awkward. Optional: (vars as Record<string, number>)[ch] = next. (nit)

What I didn't verify

  • GSAP's documented contract for mutating vars on a live tween + invalidate() — I trusted the comments and tests. If GSAP caches plugin-resolved values somewhere other than vars (e.g. an internal _pt linked list for the recompiled tween), invalidate() should rebuild it, but I didn't read the GSAP source to confirm scale/scaleX/scaleY interactions.
  • Whether resolveRuntimeTween's "first match" preference is acceptable for the multi-set case mentioned above — depends on whether the studio source writer can ever produce two separate sets for the same selector. Worth confirming with Miguel.

— Rames D Jusso

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at dc79a2a6. Foundation slice — concur with @james-russo-rames-d-jusso that the helper itself meets its declared contract and the 12-scenario decline matrix is comprehensive. The channel-blind tween resolution concern Rames flags as biting at #1613 is the live one; this PR alone is NIT.

Verified at HEAD — full decline-path test coverage:

patchRuntimeTweenInPlace's 6 documented decline paths each have explicit tests in gsapRuntimePatch.test.ts:

  • no matching tween: :315 "returns false when the selector has no matching tween"
  • no element: :329 "returns false when the selector resolves to no element"
  • motion-path arc: :338 "returns false for a motionPath arc tween (defers to soft reload)"
  • dynamic values: :374 "returns false for a dynamic/computed keyframe value"
  • set-vs-keyframes shape mismatch: :407 "returns false for a keyframes change against a set-only tween (shape mismatch)"
  • exploding vars/getChildren: :422 "never throws — returns false on internal error" (covers both vars-throw and getChildren-throw in one fixture)

Bonus: composition isolation at :456. The "never throws" test is the right defensive-by-default contract; band-aid pattern #4 (catches-its-own-throw) is NOT present because the catch's behavior is observable (return false) and tested.

RuntimeTweenChange.SetPatchProps channel scope: verified x, y, rotation, scaleX, scaleY, scale, opacity — no silent expansion.

Concur with @james-russo-rames-d-jusso (foundation concerns that bite at #1613):

  • Channel-blind tween resolution: resolveRuntimeTween(iframe, selector, "set") returns the first zero-duration tween matching the selector — no channel match. But sibling callers findPositionSetAnimation / findRotationSetAnimation (in gsapDragCommit.ts:319/389) discriminate by "x" in props || "y" in props vs "rotation" in props. For a selector with separate tl.set("#el",{x,y}) and tl.set("#el",{rotation}) calls, source-side writer picks correct animationId, but runtime patcher writes into whichever zero-duration tween came first — potentially the rotation tween for a position drag. Source file is correct; runtime contradictory until next full reload. Maps to band-aid pattern #2 (contradictory rules: source picks by channel, runtime picks by position-in-list) + #4 (defensive premise is wrong).

    The concern is foundation-level — this PR enables the failure, but it can't fire until a caller wires instantPatch (which #1613 does). I'd take Rames's fix-option (a): accept a channels hint to resolveRuntimeTween and prefer the tween that already carries the channel. Cheap and closes the gap before it bites.

  • patchSet dynamic-string-channel overwrite: patchSet writes channels onto vars only if Number.isFinite(next), but doesn't reject existing dynamic-string channels (e.g. vars.x = "+=10" → numeric overwrite drops dynamic intent). keyframesAreStatic has the analog for keyframes; missing here. Today's caller (drag from static element) doesn't hit this, but the foundation is general; cheap guard for keyframe-value-edit callers later.

Rest of nits (inner try/catch redundancy; type-cast readability) concur as cosmetic.

Verdict: NIT (foundation-level). Per Rames's own framing — "this bites #1613, not this PR per se, but the helper enables it" — I agree. The decline-matrix is complete as documented; the missing clause (channel discrimination) is what makes the wiring at #1613 reach a wrong-tween write. Fix the channel-hint at the resolver level here OR strengthen the caller at #1613. Either-or, not both.

Review by Via

…lace

Defensive runtime helper: locate the element's tween in window.__timelines via the
shared resolveRuntimeTween scan, update its set/keyframe vars, invalidate, and re-seek
the playhead — without re-running the whole composition. Returns false (caller falls
back to soft reload) for any shape it can't safely patch (no tween, dynamic/computed
keyframes, motionPath arc, channel mismatch, or any error). Foundation for instant,
flicker-free manual edits.
…cline dynamic-expression patches

- resolveRuntimeTween gains an optional channels[] hint; for kind:set it prefers the set whose
  vars carry one of the patched channels and never returns a disjoint-only set (e.g. won't write
  {x,y} into a co-located {rotation} set). patchRuntimeTweenInPlace derives channels from the props.
- patchSet declines (returns false → soft reload) when overwriting a string/dynamic vars[ch],
  instead of silently dropping the computed expression.
@miguel-heygen miguel-heygen force-pushed the feat/studio-gsap-keyframe-editing branch from c3ec03a to 5ef68d0 Compare June 20, 2026 21:20
@miguel-heygen miguel-heygen force-pushed the feat/instant-patch-helper branch from dc79a2a to 2a0df60 Compare June 20, 2026 21:20
miguel-heygen added a commit that referenced this pull request Jun 20, 2026
…tion, patch both coalesced commits, wire onAsyncFailure

- commitStaticGsapPosition/Rotation derive instantPatch.change.props from the actual
  update-property mutation(s) sent (one source of truth → findUnsafeMutationValues-validated
  values flow into the patch; can't drift).
- Coalesced x/y: the intermediate x commit also carries instantPatch{x}, the y commit {x,y},
  so a second-POST failure still leaves the preview patched for what persisted.
- applyPreviewSync passes reloadPreview as onAsyncFailure (plugin-CDN load error → full reload);
  per U4 the synchronous false still does NOT escalate.
- (channel disambiguation from #1612 verified end-to-end: {x,y}→position set, {rotation}→rotation set.)
@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Subsumed by #1605 (retargeted to main with the full stack)

miguel-heygen added a commit that referenced this pull request Jun 22, 2026
* chore(producer): shim __filename/__dirname in the CJS banner

Bundled CJS deps like wawoff2 call __dirname; without the shim they throw
"__dirname is not defined in ES module" at render time. Also ignore .zed/.

* chore(producer): use a template literal for the CJS banner (review nit)

* feat(core): add GSAP keyframe + motion-path source mutations

Array-form keyframe removal in both the recast and acorn writers, plus
update/add/remove-motion-path-point and add-motion-path. Exclude _auto and
data from tween property-group classification.

* fix(core): address #1554 review — data-exclusion test, split-fix doc, motion-path sentinel, parity blocks

- Regression test for the `data` GSAP-key exclusion (parallel to _auto).
- splitAnimationsInScript: documented that .fromTo()/.to() correctly stay out of the
  from-branch (only .from() reverts) and the <= boundary; added mid-flight straddle tests.
- addMotionPathToScript failure path returns id: null (was empty-string sentinel); caller updated.
- Parity blocks for addKeyframeToScript array-form + updateKeyframeInScript (mirroring
  removeKeyframeFromScript). Surfaced a latent acorn array-form partial-props merge bug —
  documented as it.skip with a ready assertion (acorn cutover follow-up).

* feat(core): route motion-path mutations through studio-api + fix clip stamping

Wire the new mutations into the file save route. Only authored clips suppress
descendant stamping, so auto-stamped animated scenes can inline-expand.

Hide in-flow timed clips with `display:none` only when they are LEAF clips (no
nested timed clips). `display:none` on a container removes its whole subtree,
hiding descendants that are still inside their own visibility window — e.g. an
in-flow composition root whose effective window clamps to the timeline end would
black out a child video that should still show (the hdr-hlg regression).
Containers keep `visibility:hidden`, which a visible descendant can override; only
leaves leave the flow, which is all the split-overlap case needs.

* feat(core): strip legacy path-offset/rotation + drop obsolete studio lint rule

A position or rotation add/set mutation makes the GSAP timeline the single source of
truth for that channel, so any lingering --hf-studio-offset / --hf-studio-rotation CSS
var must be cleared to avoid double-applying. stripStudioEditsFromTarget now clears both
channels, and the add-strip fires for the position AND rotation property groups.

Also removes the obsolete `gsap_studio_edit_blocked` lint rule: it warned that Studio
cannot save drag/resize edits to elements in a registered timeline — the exact premise
the single-source work inverts (the timeline is now the edit target). Removed the rule,
its now-unused TIMELINE_REGISTRY_ASSIGN_PATTERN import, and its 5 tests.

* fix(core): address #1555 review — complete hold-sync, invalidate clip cache, strip rotation channel

- HOLD_SYNC_MUTATION_TYPES: add add-motion-path (load-bearing — addMotionPathToScript
  authors past t=0 → first-frame snap-to-(0,0) without the hold), update-meta,
  shift-positions, scale-positions, split-animations. (add stays out: flat tweens
  only, syncPositionHoldsBeforeKeyframes is a no-op for non-keyframed tweens.)
- init.ts: timedClip in-flow/leaf WeakMaps now invalidate on clipTreeSignature change;
  visible/hidden branches both go through isTimedClipInFlow (was .get() by accident).
- keyframesWriteRotation mirrors keyframesWritePosition so a rotation-only keyframe set
  strips the stale --hf-studio-rotation channel.

* feat(studio): GSAP runtime read layer + shared helpers

* fix(studio): address #1607 review — cold-parse vs fetch-error budgets, isZeroDurationSet, array-ease tests

- useGsapAnimationFetchFallback: discriminate resolved/fetch-error/cold; only the cold
  (warm-but-zero) race gets the full ~600ms retry budget — a hard fetch error retries once.
- Extract isZeroDurationSet (was !(duration>0) duplicated); rejects NaN, documents intent.
- parsePercentageKeyframes: cite GSAP even-index spread; tests that a per-entry/interior
  ease is stripped without shifting the other keyframes' percentages.

* feat(studio): GSAP drag/commit/bridge editing infra

* fix(studio): address #1608 review — facade awaits commit, strict stale-parse guard, clearProps restore

BLOCKER: useSafeGsapCommitMutation now RETURNS the (.catch-chained) commit promise and the
commitMutation facade awaits it — so await session.commitMutation(...) resolves AFTER the
server save, fixing both consumers (useEnableKeyframes + useGestureCommit's
showToast/requestSeek/idle, which were firing before the save landed). SafeGsapCommitMutation
return type widened void→Promise<void> (fire-and-forget consumers ignore it).
- stale-parse guard uses hasNonHoldTweenForElement (a leftover hold set no longer counts as live).
- commitFlatViaKeyframes snapshots dragged gsap values before clearProps + restores after seek,
  so a failed commit leaves the dropped pose, not a cleared element.

* feat(studio): motion-path geometry + commit helpers

* docs(studio): address #1609 review — document occlusion fade-in invariant, donut limit, nearestPointOnPath t-semantics

* feat(studio): on-canvas motion-path overlay

* fix(studio): address #1610 review — scope dblclick to pan-surface, kind-aware geometry guard, gate createMode, screen-space drag threshold

* feat(studio): keyframes flag, gesture recording + timeline/selection refinements

* fix(studio): address #1611 review — fetch-first keyframe path, gated hydration, dev-gated debug + gesture warn, per-group gesture tweens

- useEnableKeyframes: parse current source first (null-vs-[] distinction) so a delete-all's
  empty parse isn't overridden by a stale selectedGsapAnimations cache.
- useStudioUrlState: freeze the hydration effect's time dep once hydrated (was re-running every tick).
- useGestureRecording: dev-gated console.warn when the live-preview runtime throws (was silent).
- playerStore: gate window.__playerStore behind dev (guarded import.meta.env.DEV).
- useGestureCommit: partition recorded keyframes by property group → one add-with-keyframes per
  group, so a mixed gesture no longer yields an untagged legacy tween.

* feat(studio): single-source manual offset + rotation via the GSAP timeline

Dragging or rotating an element writes into the GSAP timeline (the single source of
truth) instead of a parallel --hf-studio-offset / --hf-studio-rotation CSS var: static
elements commit a tl.set (idempotent on re-edit), tweened elements edit keyframes, and
the live preview moves via gsap.set so what you see equals what is written and renders.
Removes the dual-channel CSS-var/transform reconciliation behind the
fling / disappear / runaway / double-stack / wrong-start bug class — for BOTH position
and rotation (gesture base read from the gsap transform, gsap.set live preview, tl.set/
keyframe commit, dropped the handleDom*Commit CSS fallbacks).

Subcompositions edit the same single-source way, which surfaced and fixes:
- resolve a subcomp element's source file via the composition-id map (the runtime drops
  the source linkage when inlining the subcomposition);
- a selected element's selection box AND motion path use basic visibility, not the
  occlusion heuristic (a backgroundless opacity-1 scene above it is not an opaque cover);
- soft reload rebuilds ONLY the committed composition's timeline, leaving other
  compositions' timelines intact (no cross-composition revert);
- read keyframes from the element's OWN composition timeline (scan all timelines, not
  the first unstable key);
- delete-all uses a soft reload too, so editing no longer hard-reloads the iframe.

* fix(studio): address #1567 review — drop drag-intercept flag, harden softReload onerror, tighten runtime ladder, per-group gestures

- DROP STUDIO_GSAP_DRAG_INTERCEPT_ENABLED: single-source GSAP intercept is the only
  position/rotation channel; the false branch silently killed drag+rotate (and let GSAP
  elements into the keyframe-corrupting CSS path). Removed flag + dead branch + env def + tests.
- gsapSoftReload: plugin onerror no longer fakes success — signals onAsyncFailure so the caller
  full-reloads; honors __hfMotionPathPluginLoading so a concurrent reload can't queue a dup script.
- gsapDragCommit: resolveDragRuntime narrows the as-any ladder; a mid-seek throw logs + drops
  partial reads (no phantom identity) and re-applies the drag override in finally.
- MotionPathOverlay: park-timer cleanup keyed on animId change.
- useGestureCommit: partitionKeyframesByGroup wraps the add-with-keyframes sites (per #1611 review).

* feat(studio): patchRuntimeTweenInPlace — update a tween's values in place

Defensive runtime helper: locate the element's tween in window.__timelines via the
shared resolveRuntimeTween scan, update its set/keyframe vars, invalidate, and re-seek
the playhead — without re-running the whole composition. Returns false (caller falls
back to soft reload) for any shape it can't safely patch (no tween, dynamic/computed
keyframes, motionPath arc, channel mismatch, or any error). Foundation for instant,
flicker-free manual edits.

* fix(studio): address #1612 review — channel-aware set resolution + decline dynamic-expression patches

- resolveRuntimeTween gains an optional channels[] hint; for kind:set it prefers the set whose
  vars carry one of the patched channels and never returns a disjoint-only set (e.g. won't write
  {x,y} into a co-located {rotation} set). patchRuntimeTweenInPlace derives channels from the props.
- patchSet declines (returns false → soft reload) when overwriting a string/dynamic vars[ch],
  instead of silently dropping the computed expression.

* feat(studio): instantPatch fast path in runCommit

A commit carrying an instantPatch option tries patchRuntimeTweenInPlace first; on
success the preview updates in place with NO reload (instant), on false it falls back
to the existing soft reload. Extracts the preview-sync tail into a testable
applyPreviewSync helper. No behavior change when instantPatch is absent.

* feat(studio): route static position/rotation set drags through instantPatch

Static-element position and rotation set commits now attach instantPatch{selector,
change:{kind:set}} so the drag updates in place with no reload. Structural ops (new
tween add, delete-all, convert/split/materialize) and keyframe edits deliberately omit
it and keep the soft reload — keyframe instant-patch needs object-form keyframe support
in patchRuntimeTweenInPlace (deferred).

* fix(studio): address #1613 review — derive instantPatch from the mutation, patch both coalesced commits, wire onAsyncFailure

- commitStaticGsapPosition/Rotation derive instantPatch.change.props from the actual
  update-property mutation(s) sent (one source of truth → findUnsafeMutationValues-validated
  values flow into the patch; can't drift).
- Coalesced x/y: the intermediate x commit also carries instantPatch{x}, the y commit {x,y},
  so a second-POST failure still leaves the preview patched for what persisted.
- applyPreviewSync passes reloadPreview as onAsyncFailure (plugin-CDN load error → full reload);
  per U4 the synchronous false still does NOT escalate.
- (channel disambiguation from #1612 verified end-to-end: {x,y}→position set, {rotation}→rotation set.)

* feat(studio): no full iframe remount for soft-reloadable edits

A softReload edit (and the SDK single-script refresh) no longer escalates to a full
reloadPreview() iframe remount when applySoftReload returns false — the live gsap.set
already shows the value, and a remount is the worst flash + re-inlines subcomps
(reverting their keyframes). verifyTimelinesPopulated now checks the expected target
keys the re-run registers, so a correct scoped re-run doesn't spuriously report empty.
Full reload stays only for the structural (no-softReload) and ambiguous-script paths.

* feat(studio): pre-load MotionPathPlugin so motion-path edits don't async-flash

ensureMotionPathPluginLoaded() runs once at the preview iframe-load seam (NLELayout
onIframeLoad), eagerly loading + registering MotionPathPlugin without killing the
timeline. So when a user adds a motion path to a composition that didn't originally
use one, the soft reload runs synchronously instead of taking the kill-then-await-CDN
async path (the flash). Idempotent + defensive; the existing async fallback stays for
genuine cold-start/CDN-failure.

* fix(studio): don't re-save + reload when source editor syncs externally

The SourceEditor's CodeMirror update listener fired onChange on ANY docChanged —
including the programmatic dispatch that syncs external content (e.g. a manual-edit
commit writing the source back into the open editor). That made the editor re-save the
file and bump refreshKey, fully reloading the preview iframe on every drag/keyframe
edit — defeating the in-place instant patch and causing the flash. Annotate the
programmatic sync (ExternalSync) and skip onChange for it, so only real keystrokes save.

* fix(core): inject MotionPathPlugin into preview when a composition uses motionPath

A studio-created motion path writes a gsap motionPath tween into the single-source
timeline, but the preview HTML only loaded gsap core — so the first render threw
"Invalid property motionPath ... Missing plugin?". Detect motionPath usage and inject
MotionPathPlugin right after the composition's gsap script, version-matched to it.

* fix(studio): dedup __hfMotionPathPluginLoading type decl (restack artifact)

* fix(studio): address #1605 review — distinguish soft-reload failure modes + observability, SourceEditor focus guard

BLOCKER: applySoftReload now returns SoftReloadResult ('applied' | 'verify-failed' |
'cannot-soft-reload') instead of a bare bool. applyPreviewSync + sdkRefresh escalate to a full
reloadPreview() on the PERMANENT 'cannot-soft-reload' (no gsap/rebind hook/scopable key/script,
or sync re-run threw) — fixing the silent-stale-preview U4 dropped — but still suppress the
TRANSIENT 'verify-failed' (live gsap.set is correct). Telemetry: gsap_soft_reload_outcome
(origin/result/escalated) + gsap_instant_patch_fallback, so the U4 invariant is enforced, not asserted.
- SourceEditor: skip the programmatic external-sync replace while the editor is focused, so an
  in-flight commit doesn't clobber the user's uncommitted keystrokes (ExternalSync kept for unfocused).
- Verified ensureMotionPathPluginLoaded already guards __hfMotionPathPluginLoading (no double-append).

* fix(core): align __clipTree and __clipManifest ids via stableClipId

Timeline inline expansion was dead for nested children inside index.html:
the tree keyed id-less elements by a synthetic __clip-N while the manifest
keyed them null, so parent<->child never joined. Both now resolve identity
through stableClipId (id || data-hf-id), which every generated element has.

* fix(core): strip baked runtime + tag comp root in preview assembly

Comps that ship a baked inline runtime were double-loaded (preview injects
its own) and the baked copy failed to parse inline (Unexpected token '<').
Strip it in buildSubCompositionHtml + the disk-fallback preview path. Also
tag the comp root with data-composition-file so the studio resolves a comp's
top-level elements to the right source file instead of defaulting to
index.html (which made the GSAP panel parse the wrong, multi-timeline file).

* feat(studio): set motion-path destination from a toolbar toggle

Replaces the double-click-on-canvas UX (which painted text over the preview)
with a 'Set motion destination' toggle next to Snap/Grid, shown only when the
selected element can take a path. While armed, one canvas press places the
destination. Also removes the dead TimelinePropertyRows component.

* fix(studio): center timeline keyframe diamonds on their percentage

Dropped clampDiamondLeft, which forced boundary keyframes fully inside the
clip so a 0% diamond sat half a diamond right of the 0% point. Each diamond's
midpoint now sits exactly on its % (the clip is overflow-visible).

* fix(studio): resize static elements via tl.set, not a single-stop keyframes tween

Resizing an element with no size animation wrote keyframes:{ <playhead%>:
{width,height} } — one mid-point stop GSAP can't interpolate, so it rendered
NaN/0 dimensions at every other frame and the element vanished (worst off 0%).
Added commitStaticGsapSize (mirrors commitStaticGsapPosition): a static resize
now writes tl.set({width,height}), held at all frames; re-resizing updates it
in place.

* fix(studio): negative-cache failed media probes

Only successful probes were cached, so CORS/404 cross-origin media was
re-probed every rAF-driven timeline re-derive, flooding the console. Remember
failed URLs and skip them.

* fix(studio): type window.setTimeout handle as number

ReturnType<typeof window.setTimeout> infers NodeJS.Timeout when @types/node is
present and clashes with the DOM number the call returns. Type it number.

* fix(studio): drag/resize disappearance, stale-ID duplicates, soft-reload clearProps

- Fix soft-reload clearProps destroying element inline styles — save cssText,
  clear, restore, strip only transform
- Fix resize no-op on re-resize: delete+add instead of two update-property
- Route set tweens through static resize path (convertToKeyframes skips sets)
- Re-fetch animation ID before drag commit to prevent stale-ID duplicates
- Guard editDebugLog for Node test environments
- Fix NLELayout setState-during-render (move reset to useEffect)
- Stop SnapToolbar pointer events propagating to canvas deselect handler
- Enable click-to-add waypoints on cubic motion paths
- Add whole-path drag offset (Alt+drag shifts all keyframes together)
- Add Canvas shortcuts section to ShortcutsPanel
- Extract useMotionPathData + commitGsapPositionFromDrag (filesize compliance)
- Delete dead code (getElementDepth, isElementVisibleInPreview, unused exports)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants